04 - 语义约定与内容治理
前置:02 篇的埋点路线。
本篇回答:span 上的字段该叫什么名字(约定),以及 prompt 和模型输出这些字段值到底记不记(内容治理)。这两件事看着是两个话题,但决定它们的开关经常在同一个配置对象里。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| 语义约定 | 规定 span 叫什么名、带哪些属性的标准。有它,不同厂商的工具才读得懂同一份数据 |
| 命名空间 | 属性名的前缀。gen_ai.* 和 llm.* 是两个不同命名空间,表达的却常是同一件事 |
| OTTL | OpenTelemetry Transformation Language,在 Collector 里改写遥测数据的表达式语言 |
| 归一化 | 把不同来源、不同命名的数据改写成同一套字段名 |
| 索引属性 | 形如 llm.input_messages.0.message.role 的属性 —— 数组下标编在属性名里,条数越多属性越多 |
一、两套约定的定位
| OpenTelemetry GenAI | OpenInference | |
|---|---|---|
| 仓库 | open-telemetry/semantic-conventions-genai | Arize-ai/openinference |
| ★ | 267 | 1,159 |
| 协议 | Apache-2.0 | Apache-2.0 |
| 属性前缀 | gen_ai.* | llm.* document.* embedding.* |
| 出身 | OpenTelemetry 官方 | Arize(Phoenix 的开发方) |
| 定位 | 通用可观测标准的 GenAI 扩展 | 面向 LLM 应用调试的实用约定 |
不要用 star 数比较这两个仓库。 使用者 star 的是 SDK 和平台,不是规范文本 —— semantic-conventions-genai 只有 267 星,不代表它边缘。判断采纳度看的是哪些平台和基础设施项目在生产路径上实现了它,第三节给证据。
二、覆盖范围不同
两套的差异不在命名风格,而在建模粒度:
最后一行的 MCP 空缺不是我推断的。Envoy AI Gateway 的源码注释里写着:
// OpenInference defines no MCP conventions, so MCP spans keep the
// gateway-specific vocabulary they have always emitted.
mcp: mcpVocabularyLegacy(),
选了 OpenInference,MCP 那部分 span 就得各家自己发明词汇。 而 OTel GenAI 那边有一份 1,332 行的 docs/gen-ai/mcp.md。
三、生态站队:一个产品同时实现两套
判断采纳度最可靠的信号,是看基础设施项目在生产路径上用了谁。Envoy AI Gateway(★1,943,Apache-2.0)给了一个完整的样本 —— 它的 internal/tracing/ 下两套都实现了,由环境变量选:
// internal/tracing/semconv.go
const EnvTracingSemConv = "AI_GATEWAY_TRACING_SEMCONV"
// 列表第一项是默认值 —— 也就是说不配的话走 OpenInference
var semConvs = []semConv{
{name: "openinference", newRecorders: newOpenInferenceRecorders},
{name: "gen_ai", newRecorders: newOTelGenAIRecorders},
}
// 值写错了直接启动失败,不做静默兜底。注释解释了为什么:
// 「一个网关连续几个月发着没人在看的约定,比拒绝启动更糟」
func newRecordersFromEnv() (recorderSet, error) {
name := os.Getenv(EnvTracingSemConv)
if name == "" { return semConvs[0].newRecorders(), nil }
for _, sc := range semConvs {
if sc.name == name { return sc.newRecorders(), nil }
}
return recorderSet{}, fmt.Errorf("invalid %s %q: must be one of %s", ...)
}
从这段代码能读出三件事:
- 两套都得支持 —— 一个 CNCF 生态的网关项目,没法只押一边。
- 默认是 OpenInference —— 至少在追踪这条路径上,它比"官方"更实用。
- 而它的 Prometheus 指标那一侧走的是 OTel GenAI 约定。同一个产品,指标一套、追踪另一套。
第三条最能说明问题:这不是"哪套会赢"的竞争,是两套在不同信号上各自扎根。
3.1 一条藏在同一个文件里的生产坑
同一段代码里还有个字段,注释值得逐字读:
// unboundedAttributeCount reports whether this convention emits indexed
// per-message attributes. Those scale with conversation length and exceed
// OTEL's default cap of 128, silently truncating spans.
unboundedAttributeCount: cfg.CapturesMessages(),
OpenInference 记消息的方式是 llm.input_messages.0.message.role、llm.input_messages.0.message.content、llm.input_messages.1.message.role……下标编在属性名里,一轮对话两三个属性。
而 OpenTelemetry SDK 的 OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT 默认值是 128。
| 对话轮数 | 属性数量 | 结果 |
|---|---|---|
| 十轮以内 | 几十个 | 正常 |
| 四十轮以上 | 超过 128 | 超出的属性被静默丢弃 |
现象是:短对话的 trace 完整,长对话的 trace 缺尾部消息 —— 而长对话恰恰是最需要排查的那些。Envoy AI Gateway 的做法是只在该约定确实要记消息时才抬高上限;自己搭埋点的话,这个上限要手动调:
# 记消息内容时必调。注意抬高上限意味着单个 span 变大,
# 这笔账要和 05 篇的存储成本一起算
export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=1024
OTel GenAI 那边把整段消息放进 gen_ai.input.messages 一个属性里(序列化成 JSON 字符串),不吃这个限制 —— 但换来的是这个属性的值可能超过 OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT,被从中间截断。两套各有各的截断方式,都得管。
四、收敛:三个位置可以做归一
既然两套都要活着,问题就变成"在哪一层把它们统一"。有三个位置,代价依次降低:
4.1 位置②:埋点库的双发开关
OpenInference 的配置里有这么一项:
# openinference/instrumentation/config.py
OPENINFERENCE_ENABLE_GENAI_SEMCONV = "OPENINFERENCE_ENABLE_GENAI_SEMCONV"
# Emits OTel GenAI semantic conventions alongside OpenInference attributes
DEFAULT_ENABLE_GENAI_SEMCONV = False
打开之后,同一个 span 上同时带 llm.* 和 gen_ai.*。适用场景很窄:新旧后端并行的迁移期,两边都得喂数据。稳定之后应该关掉 —— 每个 span 体积接近翻倍,这笔账在 05 篇会具体算。
4.2 位置③:Collector 侧的官方归一组件
opentelemetry-collector-contrib 里有一个 alpha 阶段的处理器,作用是"把非 OTel 埋点库产生的 span 属性改写成 OTel GenAI 约定":
processors:
gen_ai_normalizer:
sources:
# openinference 和 openllmetry 是内置源,映射表写死在组件里,
# 不用自己维护对照关系
- name: openinference
remove_originals: true # 改完把原属性删掉,否则一个 span 上两套都在
overwrite: false # 目标属性已存在时跳过而不是覆盖
- name: openllmetry
remove_originals: true
# 也可以自定义源,给自研埋点用
- name: my-legacy-sdk
mappings:
"myapp.llm.prompt_tokens": "gen_ai.usage.input_tokens"
value_mappings:
"gen_ai.operation.name":
"completion": "chat" # 值也能折叠,不只是改键名
service:
pipelines:
traces:
# 必须排在依赖上下文的处理器之后,比如 k8sattributes
processors: [k8sattributes, gen_ai_normalizer, batch]
这件事以前要各家自己写映射表,现在是 OTel 官方组件。 这直接改变了"两套约定怎么办"的答案:不需要在业务代码里选边,也不需要写转换层。